Skip to content

#1370 - support import and export of PMC CSVs#1376

Closed
alitheg wants to merge 1 commit into
mainfrom
arm
Closed

#1370 - support import and export of PMC CSVs#1376
alitheg wants to merge 1 commit into
mainfrom
arm

Conversation

@alitheg

@alitheg alitheg commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Adds support for import/export of a PMC's projects, releases, artifacts by an admin.

For review - it's a decent sized change and likely wants some manual testing on dev once deployed! I can do that once it's merged to tertia

@alitheg alitheg requested a review from sbp July 8, 2026 16:35
Comment thread atr/storage/writers/catalogue.py Outdated
via = sql.validate_instrumented_attribute
old_key = move.key
version = str(move.row.version)
new_key = f"{move.to_project}-{version}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the uploader gets the version wrong, couldn't this corrupt the release? There doesn't appear to be any validation of this new_key. I tested, and if there are artifacts then you get a 500 error anyway, but it breaks the whole import. If there are no artifacts, it corrupts silently. I assume that this will cause problems elsewhere, but I didn't check (I mean, release key is proj-0.1 but version is 0.2).

@alitheg alitheg Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quite right, and that hits something that bugged me when I did this - I've changed to start deriving the release keys instead of exposing them. For the scope of this page, it doesn't really matter that you can't change a key, you create a new one and reassign the artifacts to it - you can always remove the empty release as a separate operation by merging it with another.

There was another bug here - if you change version and nothing else your edit was silently dropped.

path = str(row.artifact_path)
pk = (str(row.project_key), str(row.version), path)
suffix = str(row.download_path_suffix) if row.download_path_suffix else ""
matched = snapshot.artifact_by_dist.get((suffix, path)) if suffix else None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is looking up artifacts by (download path, filename)? Problem is, since download path is an exported field, it's also editable. If anybody changes it, how will it know what's being moved? So, any download path edit becomes a crash: it's an actual 500.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path is the identity, so we should be using it to look up the existing one, and it's the only real conflict in seeding/correcting artifacts (you can't use the same full artifact path in more than one release). The add path now checks to make sure there's no conflict there, but I'm considering actually having the uploaded artifact CSV be an exhaustive list - remove all that don't match from the same PMC. This is for correcting the seeds, after all.

continue
current_project = snapshot.release_project.get(key)
if current_project is None:
diff.adds.append(row)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess if target_release_key in snapshot.release_keys was intended to be the global check here (it's below in the code), but because there's a continue it's never reached. Basically this check is only looking at whether it's a release key in the existing committee, whereas the later check, which is not reached, would check whether it's a release key in the whole database. Small point, but also easy to fix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good spot. It's the two separate branches - the latter being one for moving to another committee. So we don't actually check existence when we're adding a new row! That second check is still correct for moving a release, but we do need to check for conflict in the first case.

.values(version_key=new_key, project_key=move.to_project)
)
# Other release-key children re-point to the new key
for model, attr in repoint.RELEASE_KEY_REFS:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This contains sql.Task.version_key, but it shouldn't do because that's not a release key (example-0.1), it's just the version (0.1):

RELEASE_KEY_REFS: Final[list[tuple[type[sqlmodel.SQLModel], str]]] = [
    (sql.Release, "key"),
    (sql.Artifact, "release_key"),
    (sql.LifecycleEvent, "version_key"),
    (sql.CheckResult, "release_key"),
    (sql.BallotPaper, "release_key"),
    (sql.Distribution, "release_key"),
    (sql.Quarantined, "release_key"),
    (sql.Revision, "release_key"),
    (sql.Task, "version_key"),
]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I think Tasks will have to be handled separately.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The (sql.LifecycleEvent, "version_key") entry looks suspicious too, but I didn't check it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues here - 1. Claude was overzealous in finding release keys and I missed it (it identified version_key there because it is called that in LifecycleEvent - I'll raise a bug and fix later). 2. I neglected to note in this part that tasks don't need moved since the whole admin page doesn't let you edit projects that have already started using ATR properly (since mangling releases, artifacts etc including on-disk and draft etc. just seems fraught with peril). This is purely for catalogued projects/releases/artifacts which exist in SVN but have no ATR-managed files anywhere. Which won't have tasks!

existing_pks = {(pk[0], pk[1], pk[2]) for pk in snapshot.artifact_by_dist.values()}
for row in rows:
reference_conflict = _artifact_reference_conflict(row, snapshot, known_projects, known_releases)
if reference_conflict is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only seems to check the destination, not the source.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Below, if current_project in snapshot.managed_project_keys checks the source for releases. Artifact sources aren't checked.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a check actually intended to prevent using these tools to manipulate ATR-managed releases (where we'll have on-disk files, revisions, etc). But we should be checking the source as well.

@alitheg alitheg force-pushed the arm branch 4 times, most recently from dba3e2d to 989ab53 Compare July 13, 2026 16:14
@alitheg

alitheg commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

@sbp I've updated this so you can now choose to do an overwrite all. In most real cases, we'll be doing this - we'll exclude a complex PMC the seed script, then manually seed them through this. In some other cases we might want to just upload a few, which the additive path will now do. Additive is default in the UI since it's non-destructive. I need to do some testing, but further comments now welcomed/invited!

@sbp sbp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not finished yet. May have a few more findings to come in a subsequent review, but you could commit if you like and we can do those as fixups.

Comment thread atr/admin/__init__.py Outdated
projects: form.File = form.label("Projects CSV", "Optional.")
releases: form.File = form.label("Releases CSV", "Optional.")
artifacts: form.File = form.label("Artifacts CSV", "Optional.")
additive: bool = form.label(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This field is default=True, but because browsers don't send anything when the field is unchecked, that's when we apply the default. So when the field is checked, the browser sends "on" and we set True. If the field is unchecked, the browser sends nothing, and we use the default, which is True. Therefore it's impossible to turn this field off.

I wonder if we can add a static lint for this. Might be difficult because the field element is a consequence of the Python type of the property.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, valid. Yes we should consider a lint for it as a possibility. I've reversed the logic so it's now checked == overwrite

Comment thread atr/shared/catalogue_diff.py Outdated
pk = (str(row.project_key), str(row.version), path)
# Two rows sharing a dist path would be two catalogue entries for one file in svn
dist = (str(row.download_path_suffix), path) if row.download_path_suffix else None
if (pk in seen_pks) or ((dist is not None) and (dist in seen_dists)):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So seen_dists is what was seen in the file, not in the db? But ATR managed projects are skipped, so if the uploaded CSV contained a file in an unmanaged project that was actually in a managed project, would you not end up with a duplicate? We check that it's not duplicated within the CSV, that the target project isn't managed in ATR, that the committee and release exist, and that the primary key is free, but all of those would pass in this circumstance and yet there'd still be a duplicate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've unified the logic between the two paths and made sure we're checking existing managed projects too, not just the snapshot of projects which match those uploaded.

| {f"{move.to_project}-{move.row.version}" for move in diff.release_moves}
)
seen_dists: set[tuple[str, str]] = set()
seen_pks: set[tuple[str, str, str]] = set()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can move a release in releases, and that moves its artifacts along with it. If you then do an add in artifacts (the separate field for that), using the same name as an artifact's destination in the previous step, the code creates two duplicate rows for the artifact. This then fails because of the unique constraint in the database.

Here's a test case for this one:

def test_classify_collision() -> None:
    snap = _snapshot(
        project_committee={"alpha-one": "alpha", "alpha-two": "alpha"},
        artifact_pks=frozenset({("alpha-one", "1.0.0", "a.tgz")}),
    )
    rows = {
        "releases": [_release("alpha-one-1.0.0", "alpha-two", "1.0.0")],
        "artifacts": [_artifact("alpha-two", "1.0.0", "a.tgz", "alpha/new")],
    }
    d = catalogue_diff.classify_additive(rows, snap, "alpha")
    assert d.counts["conflict"] == 1

(This fails, but it shouldn't.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the test, and good catch! It now tracks the resulting primary keys as it goes along, meaning we should catch duplicates that are both in the uploaded files.

@sbp sbp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One tiny comment. I was going to comment on the round tripping breaking, but that appears to be fixed now!

}

# The uploaded CSVs live here, one directory per token
IMPORT_ROOT: Final[pathlib.Path] = pathlib.Path(config.get().STATE_DIR) / "temporary" / "catalogue-imports"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or paths.get_tmp_dir gives a safe.StatePath.

@alitheg

alitheg commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Closing as I'll merge manually and don't want GH to delete my branch :)

@alitheg alitheg closed this Jul 14, 2026
@alitheg alitheg linked an issue Jul 14, 2026 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Admin page for seeding Projects and Artifact Catalog

2 participants